package cs236369.proxy;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.net.UnknownHostException;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Properties;
import java.util.StringTokenizer;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpException;
import org.apache.http.HttpMessage;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpVersion;
import org.apache.http.StatusLine;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.impl.DefaultHttpClientConnection;
import org.apache.http.impl.DefaultHttpServerConnection;
import org.apache.http.message.BasicHttpRequest;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.message.BasicStatusLine;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.util.EntityUtils;

import sql.CacheHandler;
import sql.ResponseWrapper;

import Logging.Monitor;

/**
 * The ProxyServer constructor should not construct the cache tables. These tables 
 * either already exist or explicitly constructed using constructCacheTables().
 * The proxy should start listening to connection requests only after the start() 
 * method has been called, and not when it is constructed.
 * 
 * @author Ron Galay(302470356,rongalay@gmail.com) & Artem Merkovich(309169431,artemer@gmail.com)
 *
 */
public class ProxyServer {

	private Properties p;
	
	public ProxyServer(Properties p) {
		
		this.p = p;
	}
	
	public void createCacheTable() throws SQLException, ClassNotFoundException {
		
		// creating the cache handler
		CacheHandler.createCache(p);
		Monitor.write(this, "Created our cache handler");
		
	}

	/**
	 * This method initializes an infinite loop of listening to connection 
	 * requests and handling HTTP requests of clients.
	 */
	public void start() {
		
		int port = Integer.parseInt(p.getProperty("proxy.port"));
		ServerSocket listeningSocket;
		try {
			listeningSocket = new ServerSocket(port);
		} catch (IOException e) {
			System.err.println("ServerSocket faild to create");
			e.printStackTrace();
			throw new RuntimeException("ServerSocket faild to create");
		}
		Monitor.write(this, "Waiting for connections on port:"+port);
		
		// start listening to connection requests
		while(true) {
			
			// initialize socket
			Socket serverSocket;
			try {
				serverSocket = listeningSocket.accept();
			} catch (IOException e) {
				System.err.println("ServerSocket faild to accept");
				e.printStackTrace();
				throw new RuntimeException("ServerSocket faild to accept");
			}
			Monitor.write(this, "Accepted a connection");
			HttpParams params = new BasicHttpParams();
		
			// when ever we send and receive information using the serverConn it
			// will be sent via the bound socket - it's role is to be "the Server" simulator
			DefaultHttpServerConnection serverConn = new DefaultHttpServerConnection();
			try {
				serverConn.bind(serverSocket, params);
			} catch (IOException e) {
				System.err.println("ServerSocket faild to bind");
				e.printStackTrace();
				throw new RuntimeException("ServerSocket faild to bind");
			}
			
			DefaultHttpClientConnection clientConn = new DefaultHttpClientConnection();
			
			// receiving request from client and handling the sending to server
			HttpRequest request;
			try {
				request = serverConn.receiveRequestHeader();
			} catch (Exception e) {
				HttpResponse errResponse = new BasicHttpResponse(HttpVersion.HTTP_1_1, 500, "Internal Server Error");
				try {
					serverConn.sendResponseHeader(errResponse);
				} catch (Exception e1) {
					System.err.println("Corrupt socket");
					e1.printStackTrace();
					continue;
				}finally{
					try{
						serverConn.close();
						serverSocket.close();
					}catch(Exception e2){
						continue;
					}
				}
				continue;
			}
		
			Socket clientSocket = null;
			try {
				clientSocket = getSocket(request);
			} catch (Exception e) {
				HttpResponse errResponse = new BasicHttpResponse(HttpVersion.HTTP_1_1, 500, "Internal Server Error");
				try {
					serverConn.sendResponseHeader(errResponse);
				} catch (Exception e1) {
					System.err.println("Corrupt socket");
					e1.printStackTrace();
					continue;
				}finally{
					try{
						serverConn.close();
						serverSocket.close();
						clientSocket.close();
					}catch(Exception e2){
						continue;
					}
				}
				continue;
			} 
		
		    try {
				clientConn.bind(clientSocket, new BasicHttpParams());
			} catch (IOException e) {
				HttpResponse errResponse = new BasicHttpResponse(HttpVersion.HTTP_1_1, 500, "Internal Server Error");
				try {
					serverConn.sendResponseHeader(errResponse);
				} catch (Exception e1) {
					System.err.println("Corrupt socket");
					e1.printStackTrace();
					continue;
				}finally{
					try{
						serverConn.close();
						serverSocket.close();
						clientConn.close();
						clientSocket.close();
					}catch(Exception e2){
						continue;
					}
				}
				continue;
			}
		
			if (request instanceof HttpEntityEnclosingRequest) {
				// we are handling a post request - we bypass all the cache handling
			    try {
					handlePostRequest(request,clientConn,serverConn);
				} catch (Exception e) {
					HttpResponse errResponse = new BasicHttpResponse(HttpVersion.HTTP_1_1, 500, "Internal Server Error");
					try {
						serverConn.sendResponseHeader(errResponse);
					} catch (Exception e1) {
						System.err.println("Corrupt socket");
						e1.printStackTrace();
						continue;
					}finally{
						try{
							serverConn.close();
							serverSocket.close();
							clientConn.close();
							clientSocket.close();
						}catch(Exception e2){
							continue;							}
					}
					continue;
				}
			    
			} else {
				// we are handling a get request - we handle the cache situations
				try {
					handleGetRequest(request,clientConn,serverConn);
				} catch (Exception e) {
					HttpResponse errResponse = new BasicHttpResponse(HttpVersion.HTTP_1_1, 500, "Internal Server Error");
					try {
						serverConn.sendResponseHeader(errResponse);
					} catch (Exception e1) {
						System.err.println("Corrupt socket");
						e1.printStackTrace();
						continue;
					}finally{
						try{
							serverConn.close();
							serverSocket.close();
							clientConn.close();
							clientSocket.close();
						}catch(Exception e2){
							continue;
						}
					}
					continue;
				} 
			}
			
			// closing connections
			try{
				serverSocket.close();
				clientSocket.close();
				serverConn.close();
				clientConn.close();
			} catch (Exception e){
				continue;
			}
			
		}
		
	}

	public void cacheClear() throws SQLException, ClassNotFoundException {
		
		// removing the cache
		CacheHandler.clear(p);
		Monitor.write(this, "Removed our cache named Cache");
	}
	
	public static void main(String[] args) throws Exception {
		
		// reading the properties
		Properties p = new Properties();
		p.load(new FileInputStream("config"));

		// initialing the proxy
		ProxyServer proxy = new ProxyServer(p);
		proxy.createCacheTable();
		proxy.start();
	}

////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////
	
	private void handleGetRequest(	HttpRequest request,
									DefaultHttpClientConnection clientConn,
									DefaultHttpServerConnection serverConn) 
									throws 	UnknownHostException,
											IOException, HttpException {

		boolean canCache = true;
		
		// checking if the request is in the cache
		URL url = new URL(request.getRequestLine().getUri());
		if (url.toString().length() > 512) {
			canCache = false;
		}
		
		ResponseWrapper record = null;
		try {
			record = CacheHandler.getRecord(url, p);
		} catch (Exception e) {
			canCache = false;
			Monitor.write(e);
		}
		
		if(record != null) {
			// checking if If-Modified-Since already exists in the packet
			// in case not adding it to the packet
			String lastMod = getHeaderValue(request, "If-Modified-Since");
			if(!lastMod.equals("")) {
				request.addHeader(	"If-Modified-Since", 
									record.getLastmodified().toString());
			}
			request.addHeader("Connection", "close");
		} 
		
		// removing Accept-Encoding header if exists
		if(!getHeaderValue(request, "Accept-Encoding").equals("")){
			request.removeHeaders("Accept-Encoding");
		}
		// disallowing the caching if chunked response exists in the header
		if(!getHeaderValue(request, "Transfer-Encoding").equals("")){
			canCache = false;
		}
		
		// sending the packet
		sendHeaderToServer(request,clientConn);
		// getting the response from the server
		HttpResponse response = clientConn.receiveResponseHeader();
		clientConn.receiveResponseEntity(response);
		Monitor.write(this, "Received the last-modified response");
		
		if( "NO-CACHE".equals(getHeaderValue(response, "Cache-Control").toUpperCase(Locale.ENGLISH)) ||
			"NO-STORE".equals(getHeaderValue(response, "Cache-Control").toUpperCase(Locale.ENGLISH)) || 
			"NO-STORE".equals(getHeaderValue(request, "Cache-Control").toUpperCase(Locale.ENGLISH)) ){
			try {
				CacheHandler.removeRecord(url, p);
			} catch (Exception e) {} 
			canCache = false;
		}
		
		if(!canCache) {}
		else if(	
			record != null && 
			!"NO-CACHE".equals(getHeaderValue(request, "Cache-Control").toUpperCase(Locale.ENGLISH))) {
			// in cache - sending the last-modified request to the host
			
			int statusCode = response.getStatusLine().getStatusCode();
			if(statusCode == 304) {
				
				response = createResponseFromSQL(record);
				Monitor.write(this, "Returning the response from the cache");
			} else {
				if(statusCode == 200) {
					try {
						saveInCache(url,response);
					} catch (Exception e) {}
					Monitor.write(this, "Updating the response in the cache");
				} else {
					try {
						CacheHandler.removeRecord(url, p);
					} catch (Exception e) {}
					Monitor.write(this, "Removing the response from the cache");
				}
			}
		} else {
			// not in cache - sending the last-modified request to the host
			// and saving it in cache
			try {
				saveInCache(url,response);
			} catch (Exception e) {Monitor.writeException(e);}
			Monitor.write(this, "First time writing to the cache");
		}
		
		sendResponseToClient(response,serverConn); 
	}

	private void saveInCache(URL url, HttpResponse response) 
														throws IOException, 
														ClassNotFoundException, 
														SQLException {
		
		// saving in cache
		if(!(response.getStatusLine().getStatusCode()==200 &&
			!(
			"NO-CACHE".equals(getHeaderValue(response, "Cache-Control").toUpperCase(Locale.ENGLISH)) ||
			"NO-STORE".equals(getHeaderValue(response, "Cache-Control").toUpperCase(Locale.ENGLISH))
			) &&
			(!getHeaderValue(response, "Last-Modified").equals("")))) {
			Monitor.write(this, "Violates the conditions of caching");
			return;
		}
		
		String headers = response.getStatusLine().toString()+"/r/n";
		// getting the headers
		for(Header header:response.getAllHeaders()) {
			headers += header.toString();
		} 
		
		// getting the content
		ByteArrayInputStream content = null;
		byte[] entityBytes;
		if(response.getEntity() != null) {
			entityBytes = EntityUtils.toByteArray(response.getEntity());
			response.setEntity(new ByteArrayEntity( entityBytes.clone()));
			content = new ByteArrayInputStream(entityBytes.clone());
		}
			
		String lastmodified = getHeaderValue(response, "Last-Modified"); 
		CacheHandler.insertRecord(url, headers, content, lastmodified, p); 
	}

	private HttpResponse createResponseFromSQL(ResponseWrapper record) {
		
		String headers = record.getHeaders();
		StringTokenizer headerParser = new StringTokenizer(headers, "\r\n", false);
		
		// getting the statusline
		StringTokenizer statusParser = new StringTokenizer(headerParser.nextToken(), " ", false);
		statusParser.nextElement(); // skipped the http version - we assume it to 1.1
		int statusCode = Integer.parseInt(statusParser.nextToken());
		String reasonPhrase = statusParser.nextToken();
		
		StatusLine statusLine = 
								new BasicStatusLine(HttpVersion.HTTP_1_1, 
													statusCode, reasonPhrase);
		BasicHttpResponse response = new BasicHttpResponse(statusLine);
		
		// adding all the other headers from the cache to response
		while (headerParser.hasMoreElements()) {
			String header = headerParser.nextToken();
			int delim = header.indexOf(":"); 
			response.addHeader(header.substring(0, delim), header.substring(delim)+1);
		}
		
		return response;
	}

	private void handlePostRequest(	HttpRequest request,
									DefaultHttpClientConnection clientConn, 
									DefaultHttpServerConnection serverConn) 
									throws 	HttpException,
											UnknownHostException, 
											IOException {

		serverConn.receiveRequestEntity((HttpEntityEnclosingRequest) request);
	    HttpEntity requestEntity = ((HttpEntityEnclosingRequest) request).getEntity();
		
	    sendHeaderToServer(request,clientConn);
	    if(requestEntity != null) {
	    	// sending the message with the payload
	    	clientConn.sendRequestEntity((HttpEntityEnclosingRequest) request);
    	}
        EntityUtils.consume(requestEntity);
        clientConn.flush();
        
        // getting the response from the server
		HttpResponse response = clientConn.receiveResponseHeader();
		clientConn.receiveResponseEntity(response);
		
		// sending the response back to the client
		sendResponseToClient(response,serverConn);
	}

	private void sendResponseToClient(HttpResponse response,
									DefaultHttpServerConnection serverConn) 
											throws IOException, HttpException {
		//parental control
		handleParentalControl(response);
		
		serverConn.sendResponseHeader(response);
		HttpEntity responseEntity = response.getEntity();
		if (responseEntity != null) {
			
			byte[] entityBytes = EntityUtils.toByteArray(responseEntity);
			responseEntity = new ByteArrayEntity(entityBytes.clone());
			
			response.setEntity(responseEntity);
			serverConn.sendResponseEntity(response);
			
			Monitor.write(this, "Sending response:\n" + response);
	    	Monitor.write(this, "Sent the following payload:" +	entityBytes);
		    EntityUtils.consume(responseEntity);
		}
    	serverConn.flush();	
		
	}
	
	private void handleParentalControl(HttpResponse response) throws IOException {
		//checking if needs to remove images
		if (!((p.getProperty("proxy.parental").equals("true")) &&
			( (getHeaderValue(response, "content-type").toUpperCase(Locale.ENGLISH).equals("TEXT/HTML")) ||
			(getHeaderValue(response, "content-type").toUpperCase(Locale.ENGLISH).equals("APPLICATION/XHTML+XML")) )
			) ){
			return;
		}
		
		//removing images and refreshing content-length header
		byte[] entityBytes = EntityUtils.toByteArray(response.getEntity()).clone();
		String html = new String(entityBytes);
		String newHtml = removeImgTags(html);
		response.setHeader("Content-Length", (new Integer(newHtml.length())).toString());
		response.setEntity(new ByteArrayEntity(newHtml.getBytes()));
	}
	
	/**
	 * This method changes the request received and sends it correctly to the server 
	 */
	private void sendHeaderToServer(	HttpRequest request, 
								DefaultHttpClientConnection clientConn) 
								throws 	UnknownHostException, 
										IOException, 
										HttpException {
		
        // transforming the packet
        HttpRequest newRequest = changeProxyRequest(request);
        clientConn.sendRequestHeader(newRequest);
        clientConn.flush();
        
        Monitor.write(this, "Sent this request with the uri:"+
        					newRequest.getRequestLine().getUri());
        Monitor.write(this, "Sent this request header:"+
        					newRequest.getRequestLine());
	}
	
	/**
	 * Changes the request request line to contain only a relative pass
	 */
	private HttpRequest changeProxyRequest(HttpRequest request) {
		
		String host = getHeaderValue(request, "HOST");
		String method = request.getRequestLine().getMethod();
		String originUri = request.getRequestLine().getUri();
		String uri = originUri.substring(host.length()+"http://".length());
		
		HttpRequest newRequest = new BasicHttpRequest(method ,uri ,HttpVersion.HTTP_1_1);
		for(Header header:request.getAllHeaders()) {
			newRequest.addHeader(header);
		}
		newRequest.setParams(request.getParams());
		
		return newRequest;
	}

	/**
	 * This method returns the value of a specific header from our packet.
	 * If the header doesn't exist returns ""
	 */
	private String getHeaderValue(HttpMessage message,String name) {
		
		// removing white spaces in the header name and turning it to uppercase
		String procName = "";
		StringTokenizer st = new StringTokenizer(name," ",false);
		while (st.hasMoreElements()) procName += st.nextToken();
		procName = procName.toUpperCase(Locale.ENGLISH);
		
		String value = "";
		for(Header header:message.getAllHeaders()) {
			String currHeaderName = header.getName().toUpperCase(Locale.ENGLISH);
			if (currHeaderName.equals(procName)){
				value = header.getValue();
			}
		}
		
		return value.replaceAll(" ", ""); 
	}
	
	private Socket getSocket(HttpRequest request) throws UnknownHostException, IOException {
		int port = getPortFromHost(request);
		return new Socket(getIpFromHost(request), port);
	}
	
	/**
	 * we assume that we only deal with http requests, hence the port is 80
	 * we assume that we deal with httpv 1.1
	 */
	private InetAddress getIpFromHost(HttpRequest request) 	throws 
															UnknownHostException {
		
		// getting the host name from the headers
		String host = getHeaderValue(request,"HOST");
		int dotPos = host.indexOf(":");
		if (dotPos > -1) host = host.substring(0, dotPos);
		// getting IP
		return InetAddress.getByName(host);
	}
	
	private int getPortFromHost(HttpRequest request){
		
		// getting the host name from the headers
		String host = getHeaderValue(request,"HOST");
		int posPort = host.indexOf(":");
		return (posPort > -1) ? Integer.parseInt(host.substring(posPort + 1)):80;
	}
	
	private String removeImgTags(String html) {
		return  html.replaceAll("\\<img.*?\\>", "").replaceAll("\\< *img.*?\\>", "");
	}
}